09 — 调试 UI
介绍
每个创意项目的一个重要方面,就是能够轻松地调整它。开发者(也就是你)和参与项目的其他角色(比如设计师,甚至客户)必须能够修改尽可能多的参数。
你必须把这一点考虑进来,这样他们才能找到最完美的颜色、速度、数量等等,以获得最佳体验。你甚至可能偶然得到一些看起来非常棒的意外效果。
首先,我们需要一个调试 UI。
虽然你可以用 HTML / CSS / JS 创建自己的调试 UI,但已经有很多现成的库了:
这些都能做到我们想做的事,但我们会使用 lil-gui,因为它流行、持续维护,而且易于使用。
从 dat.GUI 到 lil-gui
最初,Three.js Journey 的所有练习都使用 dat.GUI。
有一段时间,这个库一直没有更新,NPM 在安装它时开始触发漏洞警告。那些漏洞后来被修复了,但替代品开始涌现,lil-gui 也因此作为 dat.GUI 的即插即用替代品越来越受欢迎。额外的收获是,它的功能甚至更好。
所有 Three.js Journey 练习现在都用 lil-gui,但你可能会在视频里看到对 dat.GUI 的引用。忽略它们,用一样的方式使用 lil-gui 就行。
顺便说一下,GUI 是 Graphical User Interface(图形用户界面)的缩写。
示例
你可以在我的作品集网站里找到一个相当不错的调试 UI 示例。这个 UI 只在你给 URL 加上 #debug 的时候才显示。
https://bruno-simon.com/#debug
你可以调整重力、颜色、速度、元素位置等等。
虽然创建所有这些调整项花了我大量时间,但没有它,游戏看起来就没那么平衡了。
准备工作
在 starter 里,我们有那个立方体,但依赖里不包含 lil-gui。我们会把它加进去,并创建一些调整项。
实例化 lil-gui
要把 lil-gui 加到我们的项目里,我们使用 Node.js 自带的依赖管理器 NPM(就像上一课为 GSAP 做的那样)。
在终端中(服务器未运行时,或者在同一个文件夹另开一个终端窗口)运行 npm install lil-gui
lil-gui 现在已经在 node_modules/ 文件夹里了,我们可以把它导入 script.js。别忘了重启服务器:
import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import gsap from 'gsap'
import GUI from 'lil-gui'
// ... 现在你可以把 lil-gui 实例化到一个 gui 变量里,我们可以在最开头、紧接在导入之后做这件事:
/**
* Debug
*/
const gui = new GUI()
在课程后面的部分,你可能会看到更多这样的写法:
import * as dat from 'lil-gui'
// ...
const gui = new dat.GUI() 它们本质上是一回事。用你喜欢的任何写法都行。
不同类型的调整项
在体验的右上角,你可以看到一个空面板。你可以给面板添加不同类型的调整项:
- Range(范围)——用于有最小值和最大值的数字
- Color(颜色)——用于各种格式的颜色
- Text(文本)——用于简单文本
- Checkbox(复选框)——用于布尔值(
true或false) - Select(选择)——用于从值列表中选择一个
- Button(按钮)——用于触发函数
让我们看看其中的一些调整项。
Range
我们要添加的第一个调整项是范围。
大多数调整项都可以用 gui.add(...) 添加。第一个参数是对象,第二个参数是你要调整的那个对象的属性。
你需要在你创建了对象和它的属性之后,再添加 gui.add(...)。否则,你会让 lil-gui 去调整一个还不存在的东西。
让我们用 mesh.position.y 试试:
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
gui.add(mesh.position, 'y')
面板里应该会出现一个输入框。试着改变它,然后观察立方体随之移动。
要指定最小值、最大值和精度,你可以在参数里设置它们:
gui.add(mesh.position, 'y', - 3, 3, 0.01)
这样我们就得到了一个方便拖放的范围输入框。
如果你不喜欢有那么多参数,你可以在 add(...) 方法后面直接链式调用 min(...)、max(...) 和 step(...) 方法:
gui.add(mesh.position, 'y').min(- 3).max(3).step(0.01) 如果你不喜欢一行里链接太多方法,可以直接加换行:
gui
.add(mesh.position, 'y')
.min(- 3)
.max(3)
.step(0.01) 要修改标签,用 name(...) 方法:
gui
.add(mesh.position, 'y')
.min(- 3)
.max(3)
.step(0.01)
.name('elevation')
针对非属性的调整项
这里有一件重要的事要注意:lil-gui 只能修改属性。如果你想更新一个变量,那是行不通的:
let myVariable = 1337
gui.add(myVariable, '???') 但你可以用一些技巧做到这一点,比如创建一个专门用来承载属性、供 lil-gui 在这个对象上使用的对象:
const myObject = {
myVariable: 1337
}
gui.add(myObject, 'myVariable')
把这个调整项删掉,因为它只是个示例,我们稍后会在课程里实际用到这个技巧。
Checkbox
lil-gui 会自动检测你想调整的属性是什么类型,并采用对应的界面。一个很好的例子是 Object3D 的 visible 属性。它是一个布尔值,如果是 false,就会隐藏这个物体:
gui.add(mesh, 'visible')
如你所见,因为 visible 属性是布尔值,lil-gui 选择了一个复选框。
我们可以对 material 的 wireframe 属性做同样的事:
gui.add(material, 'wireframe')
Colors
处理颜色要稍微难一些。让我们试着修改 material 的 color 属性。
首先,我们需要用 addColor(...) 而不是 add(...),因为 color 属性既不是字符串、布尔值,也不是数字。它是带各种属性的对象,因为它是 Three.js Color 类的一个实例。在这些属性中有 r、g 和 b,lil-gui 可以用它们来显示漂亮的调整项:
gui.addColor(material, 'color')
你的面板里应该会出现一个颜色选择器。
现在试着从调整项里取颜色值,把它应用到 color 属性上:
你最后得到了错误的颜色:
这是因为 Three.js 应用了一些颜色管理来优化渲染。结果,调整项里显示的颜色值和内部使用的值并不相同。
我们现在不打算讨论颜色管理,把它留到以后的课程。但我们确实想要正确的颜色值。
有两种处理方式。
获取被修改后的颜色
第一种方案是,当调整项的值变化时,借助 Color 实例上的 getHexString() 方法,获取 Three.js 内部使用的颜色。
首先,我们需要感知到任何调整项的变化。为此,我们可以用 onChange() 方法:
gui
.addColor(material, 'color')
.onChange(() =>
{
console.log('value has changed')
})
接下来,我们需要访问 Color 实例,我们可以用常规的 material.color,或者直接把一个 value 作为函数的参数取出来:
gui
.addColor(material, 'color')
.onChange((value) =>
{
console.log(material.color)
console.log(value)
})
两者是 material 的 color 属性里同一个 Color 实例。
让我们使用 value,并打印 getHexString() 的结果:
gui
.addColor(material, 'color')
.onChange((value) =>
{
console.log(value.getHexString())
}) 所以,这就是你可以在代码里放心使用的颜色值。
这种技巧的问题在于,你需要打开 Console(控制台),这不太方便,尤其对设计师或客户来说。
只处理未经修改的颜色
第二种方案是,在颜色被 Three.js 修改之前就处理它。
首先,我们需要把颜色保存在 Three.js 之外的地方。我们要创建一个专门用于承载属性的对象。
我个人喜欢把它叫做 global、parameters 或 debugObject,并放在实例化 lil-gui 之后:
const gui = new GUI()
const debugObject = {} 然后,我们要给 debugObject 添加一个 color 属性,并且要在创建立方体之前做这件事,这样我们就可以立刻把它传给 MeshBasicMaterial 的 color 属性:
debugObject.color = '#3a6ea6'
const geometry = new THREE.BoxGeometry(1, 1, 1, 2, 2, 2)
const material = new THREE.MeshBasicMaterial({ color: debugObject.color, wireframe: false }) 这样做,初始颜色就只存在于一个地方了。
然后,不修改之前我们写在 material.color 上的调整项,而是把它改成 debugObject.color:
gui
.addColor(debugObject, 'color')
.onChange(() =>
{
console.log(value.getHexString())
}) 最后,不做 console.log(),而是用 set() 方法更新 material.color:
gui
.addColor(debugObject, 'color')
.onChange(() =>
{
material.color.set(debugObject.color)
})
我喜欢用这个方案,这样没人需要打开 Console,但这也取决于你。
让我们把 debugObject.color 改回 #a778d8。
函数 / 按钮
有时候,我们只是想按需触发指令。现在,我们想让立方体在点击调试 UI 的某个地方时表演一个小旋转动画。
我们可以通过把一个包含函数的属性传给调整项来实现。遗憾的是,这意味着我们不能让函数单独摆在那里再传给 lil-gui:
const myFunction = () => {
console.log('do something')
}
gui.add(myFunction, '???') 但我们可以在之前创建的 debugObject 对象上加一个 spin 属性,并把 GSAP 动画整合进去:
debugObject.spin = () =>
{
gsap.to(mesh.rotation, { duration: 1, y: mesh.rotation.y + Math.PI * 2 })
} 最后,我们可以把调整项加到 debugObject.spin 上:
debugObject.spin = () =>
{
// ...
}
gui.add(debugObject, 'spin')
你应该会看到一个 spin 按钮,点击它就会让立方体转一整圈 360°。
调整几何体
我们为什么不试着调整一下几何体的细分呢?
首先,我们把 wireframe 设为 true,以便可视化那些三角形:
const material = new THREE.MeshBasicMaterial({ color: '#9c7fe3', wireframe: true })
如果你查看 BoxGeometry 文档,你会看到控制细分的参数分别叫 widthSegments、heightSegments 和 depthSegments。
让我们试着给 geometry.widthSegments 添加一个调整项:
gui
.add(geometry, 'widthSegments')
.min(1)
.max(20)
.step(1)
我们报错是因为 widthSegments 不是 geometry 的属性。
widthSegments 只是我们在实例化 BoxGeometry 时传给它的参数。它只被用来生成一次整个几何体。
首先,因为它不是实际属性,我们需要给 debugObject 对象添加一个 subdivision 属性,并把我们的调整项应用到它上面:
debugObject.subdivision = 2
gui
.add(debugObject, 'subdivision')
.min(1)
.max(20)
.step(1)
我们把它命名为 subdivision,这样我们就把它用于 widthSegments、heightSegments 和 depthSegments 这三个参数。
接下来,当调整项的值变化时,我们要销毁旧的几何体并构建一个全新的。
为此,我们首先要监听调整项里的 onChange 事件:
gui
.add(debugObject, 'subdivision')
.min(1)
.max(20)
.step(1)
.onChange(() =>
{
console.log('subdivision changed')
})
构建几何体对 CPU 来说可能是个相当漫长的过程。现在,我们在监听 change 事件,如果用户过多地拖放范围调整项,它可能会被触发很多次。
不用 onChange,我们改用 onFinishChange,它只会在我们停止调整值时触发一次:
gui
.add(debugObject, 'subdivision')
.min(1)
.max(20)
.step(1)
.onFinishChange(() =>
{
console.log('subdivision finished changing')
})
不写那个 console.log(),我们可以用 debugObject.subdivision 构建一个新的几何体,并通过把它赋值给 mesh 的 geometry 属性来与 mesh 关联:
gui
.add(debugObject, 'subdivision')
.min(1)
.max(20)
.step(1)
.onFinishChange(() =>
{
mesh.geometry = new THREE.BoxGeometry(
1, 1, 1,
debugObject.subdivision, debugObject.subdivision, debugObject.subdivision
)
})
这就可以了,但我们犯了一个小错误。旧的几何体仍然残留在 GPU 内存的某个地方,这可能会造成内存泄漏。
要修复它,在创建新几何体之前,我们可以对旧几何体调用 dispose() 方法:
gui
.add(debugObject, 'subdivision')
.min(1)
.max(20)
.step(1)
.onFinishChange(() =>
{
mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(
1, 1, 1,
debugObject.subdivision, debugObject.subdivision, debugObject.subdivision
)
})
文件夹
假设我们的调整项有很多,调试 UI 开始变得拥挤。我们可以用 addFolder() 方法把它们分到文件夹里。
要创建一个文件夹,调用 addFolder() 并把你想要的名字作为参数传进去。确保在调整项之前创建它,并把它保存为 cubeTweaks:
const cubeTweaks = gui.addFolder('Awesome cube')
然后,不通过 gui 创建调整项,而是使用 cubeTweaks 变量:
const cubeTweaks = gui.addFolder('Awesome cube')
cubeTweaks
.add(mesh.position, 'y')
// ...
cubeTweaks
.add(mesh, 'visible')
cubeTweaks
.add(material, 'wireframe')
cubeTweaks
.addColor(material, 'color')
// ...
// ...
cubeTweaks
.add(debugObject, 'spin')
// ...
cubeTweaks
.add(debugObject, 'subdivision')
// ...
你可以用 close() 方法让它默认折叠:
const cubeTweaks = gui.addFolder('Awesome cube')
cubeTweaks.close()
虽然我们不会这么做,但你可以在文件夹里嵌套文件夹。调试 UI 凌乱可没有借口!
让我们注释掉 close():
// cubeTweaks.close()
GUI 设置
lil-gui 很灵活,我们会看一些参数、方法和技巧,把它的潜力发挥到极致。
宽度
你可以通过给 GUI 构造函数传一个带 width 属性的对象来控制宽度:
const gui = new GUI({
width: 300
})
标题
你可以用 title 属性修改面板顶部的标题:
const gui = new GUI({
width: 300,
title: 'Nice debug UI'
})
默认折叠文件夹
你可以用 closeFolders 让所有文件夹默认折叠:
const gui = new GUI({
width: 300,
title: 'Nice debug UI',
closeFolders: true
})
让我们把它改回 false:
const gui = new GUI({
width: 300,
title: 'Nice debug UI',
closeFolders: false
})
关闭
你可以调用 close() 方法关闭它:
const gui = new GUI({
width: 300,
title: 'Nice debug UI',
closeFolders: false,
})
gui.close()
注释掉 close():
// gui.close()
隐藏
你可以调用 hide() 方法把它完全隐藏:
const gui = new GUI({
width: 300,
title: 'Nice debug UI',
closeFolders: false,
})
// gui.close()
gui.hide()
切换显示
但我们怎么让它再次显示出来?这取决于你,但一个快速的方案可以是监听 keydown 事件,如果是 h 键,我们就根据 _hidden 属性(标明它当前是否隐藏的布尔值)来切换显示:
window.addEventListener('keydown', (event) =>
{
if(event.key == 'h')
gui.show(gui._hidden)
})
注释掉 hide(),让它默认可见:
// gui.hide()
更多
好了,这就是全部。但如果你好奇,可以看看 lil-gui 文档,了解更多设置和功能,比如:
- 样式
- 定位
- 属性变化时让调整项同步更新
- 其他类型的调整项,比如 Select
- 等等
结语
在接下来的练习中,我们会在特定时刻使用调试 UI。不过,你想加多少调整项就加多少。这是练习并开始创作东西的好方式。
我建议你随着进度不断添加调整项。如果你打算在项目结束时一次性加完所有调整项,你很可能一个都不会加,还会错过许多机会。
记住,下面有些课程是用 dat.GUI 而不是 lil-gui 录制的。差别不大,你应该能毫无障碍地跟上。